讓我們來開發物理包裝器元件吧!
鱈魚:「第一步從認識碰撞偵測演算法開始!◝(≧∀≦)◟」
路人:「不會吧!╭(°A ,°`)╮」
鱈魚:「的確不會,因為我也不太會。ᕕ( ゚ ∀。)ᕗ 」
路人:「那你講屁講喔。…(›´ω`‹ )」
此元件中物理模擬的部分,讓我們使用 Matter.js 實現吧。
Matter.js 是一個相當老牌、成熟的 JS 2D 物理引擎,可以模擬質量、重力、碰撞、摩擦等等複雜的物理現象,更可以建構彈簧、關節等等各種複雜的複合元件。
官方網站有相當多有趣的例子,趕快去官網看看吧。( ´ ▽ ` )ノ
那就讓我們安裝 Matter.js。
npm i -D matter-js @types/matter-js
現在讓我們開始開發吧!ヽ(●`∀´●)ノ
概念為在 Matter.js 的物理事件中建立與目標 DOM 相同尺寸的物體並模擬物理效果,同時將對應元素之狀態同步至 DOM 元素上,就可以實現物理模擬。
如同下圖概念:
就像皮影戲偶一般,DOM 元素同步在後方 Matter 物體的動作。
先來完成物理世界的部分,首先是 template。
src\components\wrapper-physics\wrapper-physics.vue
<template>
<div
ref="wrapperRef"
class="wrapper-physics relative overflow-hidden"
>
<slot />
<canvas
ref="canvasRef"
class=" absolute inset-0 pointer-events-none bg-transparent"
/>
</div>
</template>
...
slot 用於放入物理模擬的元素,canvas 則讓我們在開發中 debug 使用,用來觀察 Matter 繪製的圖形是否正確。
接下來定義元件 props 內容。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
import Matter from 'matter-js';
// #region Props
interface Props {
/** 立即開始,物體會在元件建立完成後馬上會開始掉落 */
immediate?: boolean;
/** 重力加速度
*
* x, y 為加速度的方向,scale 為加速度的大小
*/
gravity?: Matter.Gravity;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
immediate: false,
gravity: () => ({
scale: 0.001,
x: 0,
y: 1,
}),
});
...
</script>
...
gravity 的資料結構直接照搬 Matter 的 gravity 型別。◝( •ω• )◟
接下來讓我們建立 Matter 的物理世界吧,需要以下物件:
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
const {
Engine, Runner,
} = Matter;
const props = withDefaults(defineProps<Props>(), {...});
const wrapperRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();
const engine = shallowRef(
Engine.create({ gravity: props.gravity })
);
/** 同步 props.gravity 至 engine */
watch(() => props.gravity, (value) => {
engine.value.gravity = value;
}, {
immediate: true,
deep: true
});
const runner = shallowRef(
Runner.create()
);
...
</script>
...
這樣就可以建立一個 Matter 的物理世界了,是不是很簡單啊。( •̀ ω •́ )✧
現在讓我們建立一個方塊試試看會不會正常掉落。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
/** 取得 DOM 尺寸,用於指定世界範圍 */
const wrapperBounding = reactive(
useElementBounding(wrapperRef)
);
...
function init() {
const { width, height } = wrapperBounding;
const rect = Bodies.rectangle(50, 50, 100, 100);
/** 把方塊加入引擎中 */
Composite.add(engine.value.world, rect);
const render = Render.create({
canvas: canvasRef.value,
engine: engine.value,
bounds: {
min: { x: 0, y: 0 },
max: { x: width, y: height },
},
options: {
width: width,
height: height,
background: 'transparent',
wireframeBackground: 'transparent',
},
});
/** 執行 render,將畫面畫到 canvas 中 */
Render.run(render);
/** 執行 runner,開始物理模擬 */
Runner.run(runner.value, engine.value);
}
onMounted(() => {
init();
});
...
</script>
...
調整一下 basic-usage 尺寸,不然畫面高度是 0,啥都看不到。
src\components\wrapper-physics\examples\basic-usage.vue
<template>
<div ...>
<wrapper-physics class="h-[50vh]" />
</div>
</template>
...
現在可以看到畫面會有一個方塊掉落。
不過一路掉到地心,一去不復返。(́⊙◞౪◟⊙‵)
讓我們在世界邊界加上圍牆吧。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
function init() {
...
// 在邊界建立牆壁
const thickness = 100;
const boundaries = [
Bodies.rectangle(
width / 2, -thickness / 2,
width * 2, thickness,
{ isStatic: true, label: 'top' }
),
Bodies.rectangle(
width + thickness / 2, height / 2,
thickness, height * 2,
{ isStatic: true, label: 'right' }
),
Bodies.rectangle(
width / 2, height + thickness / 2,
width * 2, thickness,
{ isStatic: true, label: 'bottom' }
),
Bodies.rectangle(
-thickness / 2, height / 2,
thickness, height * 2,
{ isStatic: true, label: 'left' }
),
];
Composite.add(engine.value.world, boundaries);
const render = Render.create(...);
...
}
...
</script>
...
會發現方塊穩穩地停下來了。◝(≧∀≦)◟
物理模擬的部分沒問題了,接下來是 DOM 產生對應物體的部分。
讓我們來建立物理世界中的物體吧,首先在 wrapper-physics-body 新增 template 內容。
src\components\wrapper-physics\wrapper-physics-body.vue
<template>
<div ref="containerRef">
<slot />
</div>
</template>
...
內容非常簡單,就是一個包住 slot 的 div 即可。
接下來讓我們定義元件參數,基本上就是 Matter 的物體性質參數。
src\components\wrapper-physics\wrapper-physics-body.vue
...
<script setup lang="ts">
...
// #region Props
interface Props {
/** 空氣阻力。物體在空氣中受到的阻力 */
frictionAir?: number;
/** 摩擦力。物體本身的摩擦力,必須為 0 ~ 1,0 表示持續滑動,1 表示受力後會立即停止 */
friction?: number;
/** 回彈力。碰撞的回彈係數,0 表示不反彈,1 表示完全反彈 */
restitution?: number;
/** 物體質量 */
mass?: number;
/** 靜止。會變成像地面那樣完全靜止的存在 */
isStatic?: boolean;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
frictionAir: 0.01,
friction: 0.1,
restitution: 0.3,
mass: undefined,
isStatic: false,
});
...
</script>
...
程式邏輯也非常單純,物體只負責通知世界自身物體性質與管理自身的狀態。
先讓我們新增狀態的部分。
src\components\wrapper-physics\wrapper-physics-body.vue
<template>
<div
ref="containerRef"
:style="style"
>
<slot />
</div>
</template>
<script setup lang="ts">
...
/** 物體唯一 ID */
const id = crypto.randomUUID();
const containerRef = ref<HTMLDivElement>();
/** 取得容器尺寸與位置資訊 */
const containerBounding = reactive(
useElementBounding(containerRef)
);
/** 物體狀態,例如:偏移、旋轉等等 */
const info = ref({
offsetX: 0,
offsetY: 0,
rotate: 0,
});
const style = computed(() => {
const {
offsetX, offsetY, rotate
} = info.value;
return {
transform: `translate(${offsetX}px, ${offsetY}px) rotate(${rotate}deg)`,
}
});
</script>
現在我們把物理世界與物體都準備好了,接下來就是關鍵部分了。
所以物理世界要怎麼知道有那些物體?(́◉◞౪◟◉‵)
這裡我們使用 Vue 的 Provide / Inject,這可以讓元件提供特定的 function 或變數給後代元件使用。
過程如下:
如此我們就可以完成 DOM 元素物理模擬的效果了!◝( •ω• )◟
第一步先讓我們定義一下共用的型別與資料。
src\components\wrapper-physics\index.ts
import { InjectionKey } from 'vue';
/** 在物理世界中註冊的元素物體資訊 */
export interface ElBody {
id: string;
width: number;
height: number;
x: number;
y: number;
/** 初始值,用於計算偏移量 */
initial: {
offsetX: number;
offsetY: number;
rotate: number;
},
/** 空氣阻力。物體在空氣中受到的阻力 */
frictionAir?: number;
/** 摩擦力。物體本身的摩擦力,必須為 0 ~ 1,0 表示持續滑動,1 表示受力後會立即停止 */
friction?: number;
/** 回彈力。碰撞的回彈係數,0 表示不反彈,1 表示完全反彈 */
restitution?: number;
/** 物體質量 */
mass?: number;
/** 靜止。會變成像地面那樣完全靜止的存在 */
isStatic?: boolean;
}
export interface ProvideContent {
bindBody: (body: ElBody) => void;
unbindBody: (id: string) => void;
getInfo: (id: string) => {
offsetX: number;
offsetY: number;
rotate: number;
} | undefined;
}
export const PROVIDE_KEY = Symbol('wrapper-physics') as InjectionKey<ProvideContent>;
來新增 wrapper-physics 用於 Provide 的資料吧。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
import { onMounted, provide, reactive, ref, shallowRef, watch } from 'vue';
import Matter from 'matter-js';
import { useElementBounding } from '@vueuse/core';
import { ElBody, PROVIDE_KEY } from '.';
const {
Engine, Render, Runner, Bodies, Composite,
} = Matter;
...
/** 儲存已建立的 body */
const bodyMap = new Map<string, ElBody>();
/** body 物理模擬資料 */
const bodyInfoMap = new Map<string, {
offsetX: number;
offsetY: number;
rotate: number;
}>();
/** 註冊 body */
function bindBody(item: ElBody) {
bodyMap.set(item.id, item);
}
/** 解除 body */
function unbindBody(id: string) {
bodyMap.delete(id);
bodyInfoMap.delete(id);
}
provide(PROVIDE_KEY, {
bindBody,
unbindBody,
getInfo(id) {
return bodyInfoMap.get(id);
},
});
const wrapperRef = ref<HTMLDivElement>();
...
</script>
現在讓 wrapper-physics-body 取得 provide 內容吧。
src\components\wrapper-physics\wrapper-physics-body.vue
...
<script setup lang="ts">
import { PROVIDE_KEY } from '.';
...
const style = computed(...);
const wrapper = inject(PROVIDE_KEY);
if (!wrapper) {
console.warn('wrapper-physics-body 必須在 wrapper-physics 元件中使用');
}
</script>
沒錯,就這麼簡單。∠( ᐛ 」∠)_
新增綁定相關邏輯。
src\components\wrapper-physics\wrapper-physics-body.vue
...
<script setup lang="ts">
...
function bindBody() {
wrapper?.bindBody({
id,
width: containerBounding.width,
height: containerBounding.height,
x: containerBounding.x,
y: containerBounding.y,
initial: {
offsetX: 0,
offsetY: 0,
rotate: 0,
},
...props,
});
}
onMounted(() => {
bindBody();
});
</script>
這樣 wrapper-physics-body 元件就會在 mounted 後呼叫 wrapper-physics 的 bindBody,將自身的資料傳遞至 wrapper-physics!(/≧▽≦)/
所以現在我們有物理世界內的物體了,讓我們實現最重要的步驟,也就是初始化對應的物體。
調整一下 wrapper-physics 的 init 內容。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
/** 取得 DOM 尺寸,用於指定世界範圍 */
const wrapperBounding = reactive(...);
/** 物理世界座標初始值
*
* 以免畫面滾動後,重新建立物理世界時,物體位置不正確
*/
let wrapperInitPosition = {
x: 0,
y: 0,
}
onMounted(() => {
wrapperInitPosition = {
x: wrapperBounding.x,
y: wrapperBounding.y,
}
});
...
function init() {
const { width, height } = wrapperBounding;
/** 根據 elBody 資料建立 Matter Body */
const bodies = Array.from(bodyMap.values()).map((elBody) => {
const { width, height } = elBody;
/**
* el body 的 xy 是相對於網頁左上角為 0 點,
* 所以要先減去 wrapper 的 x, y 來取得相對於
* wrapper 的 x, y,再加上 width, height 的
* 一半,偏移自身中心
*/
const { x, y } = {
x: elBody.x - wrapperInitPosition.x + width / 2,
y: elBody.y - wrapperInitPosition.y + height / 2,
}
const {
frictionAir, friction, restitution, mass, isStatic,
} = elBody;
const body = Bodies.rectangle(x, y, width, height, {
frictionAir, friction, restitution, mass, isStatic,
label: elBody.id,
});
// 更新初始值
const data = bodyMap.get(elBody.id);
if (data) {
bodyMap.set(elBody.id, {
...data,
initial: {
offsetX: body.position.x,
offsetY: body.position.y,
rotate: body.angle,
},
});
}
return body;
})
/** 將所有 body 加入引擎中 */
Composite.add(engine.value.world, bodies);
// 在邊界建立牆壁
...
}
...
</script>
現在我們實現初始化物理世界內的物體了!╰(´︶`)╯
讓我們調整一下 basic-usage,試試看有沒有成功吧。
src\components\wrapper-physics\examples\basic-usage.vue
<template>
<div class="flex flex-col gap-4 w-full border">
<wrapper-physics class="h-[50vh] flex justify-center items-center">
<wrapper-physics-body class="border p-1">
安安
</wrapper-physics-body>
</wrapper-physics>
</div>
</template>
<script setup lang="ts">
import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';
</script>
新增一段文字試試看。
可以看到物理世界中產生了一個與文字一樣的大的物體,而且會正常掉落!♪( ◜ω◝و(و
最後我們只要不斷同步 Matter 世界中的狀態至 Vue 的變數中就可以了。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
function init() {...}
// 不斷將 Matter 狀態同步至 bodyInfoMap 中
const {
pause: pauseUpdate,
resume: resumeUpdate,
} = useIntervalFn(() => {
const list = Composite.allBodies(engine.value.world);
list.forEach((body) => {
/** id 存在 label 中 */
const id = body.label;
const info = bodyMap.get(id);
if (!bodyMap.has(id) || !info) {
return;
}
const { initial } = info;
const value = {
...{
offsetX: body.position.x - initial.offsetX,
offsetY: body.position.y - initial.offsetY,
},
rotate: body.angle * 180 / Math.PI,
}
bodyInfoMap.set(id, value);
});
}, 10);
...
</script>
body 則不斷取出對應 ID 的資料。
src\components\wrapper-physics\wrapper-physics-body.vue
...
<script setup lang="ts">
...
/** 物體狀態,例如:偏移、旋轉等等 */
const info = ref(...);
/** 調整精度,小於 0.001 數值視為 0 */
function adjAccuracy(num: number) {
return Math.abs(num) < 0.001 ? 0 : num;
}
useIntervalFn(() => {
const newInfo = wrapper?.getInfo(id);
if (!newInfo) {
info.value = {
offsetX: adjAccuracy(info.value.offsetX - info.value.offsetX * 0.05),
offsetY: adjAccuracy(info.value.offsetY - info.value.offsetY * 0.05),
rotate: adjAccuracy(info.value.rotate - info.value.rotate * 0.05),
};
return;
}
info.value = {
offsetX: info.value.offsetX + (newInfo.offsetX - info.value.offsetX) * 0.8,
offsetY: info.value.offsetY + (newInfo.offsetY - info.value.offsetY) * 0.8,
rotate: info.value.rotate + (newInfo.rotate - info.value.rotate) * 0.8,
};
}, 10);
...
</script>
同步狀態成功!✧⁑。٩(ˊᗜˋ*)و✧⁕。
現在讓我們多加一點元素吧。
src\components\wrapper-physics\examples\basic-usage.vue
<template>
<div class="flex flex-col gap-4 w-full border">
<wrapper-physics class="h-[50vh] flex flex-col justify-center items-center">
<wrapper-physics-body class="border p-1">
安安安
</wrapper-physics-body>
<wrapper-physics-body class="border p-1 mr-1">
安安
</wrapper-physics-body>
<wrapper-physics-body class="border p-1 mr-2">
安
</wrapper-physics-body>
</wrapper-physics>
</div>
</template>
<script setup lang="ts">
import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';
</script>
效果很好!(/≧▽≦)/
最後讓我們實作 wrapper-physics 元件的 immediate 參數,讓使用者可以選擇是否立即開始。
src\components\wrapper-physics\wrapper-physics.vue
...
<script setup lang="ts">
...
function init() {
...
/** 執行 render,將畫面畫到 canvas 中 */
Render.run(render);
}
function start() {
Runner.run(runner.value, engine.value);
resumeUpdate();
}
function clear() {
Composite.clear(engine.value.world, true);
Engine.clear(engine.value);
Runner.stop(runner.value);
bodyInfoMap.clear();
}
function reset() {
clear();
engine.value = Engine.create({
gravity: props.gravity,
});
runner.value = Runner.create();
init();
pauseUpdate();
}
// 不斷將 Matter 狀態同步至 bodyInfoMap 中
const {
pause: pauseUpdate,
resume: resumeUpdate,
} = useIntervalFn(() => {
const list = Composite.allBodies(engine.value.world);
list.forEach((body) => {
/** id 存在 label 中 */
const id = body.label;
const info = bodyMap.get(id);
if (!bodyMap.has(id) || !info) {
return;
}
const { initial } = info;
const value = {
...{
offsetX: body.position.x - initial.offsetX,
offsetY: body.position.y - initial.offsetY,
},
rotate: body.angle * 180 / Math.PI,
}
bodyInfoMap.set(id, value);
});
}, 10);
onMounted(() => {
init();
if (props.immediate) {
start();
}
});
onBeforeUnmount(() => {
clear();
});
// #region Methods
defineExpose({
/** 開始 */
start,
/** 重置所有元素,元素會回到初始位置 */
reset,
});
// #endregion Methods
</script>
並在 basic-usage 新增開始與重置按鈕。
src\components\wrapper-physics\examples\basic-usage.vue
<template>
<div class="flex flex-col gap-4 w-full border">
<wrapper-physics
ref="wrapperRef"
class="h-[50vh] flex flex-col justify-center items-center"
>
<div class="flex gap-4 my-6">
<wrapper-physics-body
class="border p-2 px-6 cursor-pointer select-none"
@click="wrapperRef?.start()"
>
開始
</wrapper-physics-body>
<wrapper-physics-body
class="border p-2 px-6 cursor-pointer select-none"
@click="wrapperRef?.reset()"
>
重置
</wrapper-physics-body>
</div>
<wrapper-physics-body class="border p-1">
安安安
</wrapper-physics-body>
...
</wrapper-physics>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';
const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>();
</script>
按下「開始」後才會開始物理模擬,「重置」則是可以讓所有元素回歸原位。
確定功能實現後,就不需要除錯用的 Render 了,讓我們刪除 Render 部分吧。ԅ( ˘ω˘ԅ)
src\components\wrapper-physics\wrapper-physics.vue
<template>
<div
ref="wrapperRef"
class="wrapper-physics relative overflow-hidden"
>
<slot />
</div>
</template>
<script setup lang="ts">
...
function init() {
...
Composite.add(engine.value.world, boundaries);
}
...
</script>
完成!✧⁑。٩(ˊᗜˋ*)و✧⁕。
有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭
以上程式碼已同步至 GitLab,大家可以前往下載: